跳到主要内容

SpringBoot 使用 WebSocket

使用其搭建一个聊天室案例来学习

消息格式:

// 登陆的响应
{
"flag": true,
"message": ""
}

// 客户端 --> 服务端
{
"toName": "张三",
"message": "你好"
}

// 服务端 --> 客户端
// 1. 系统消息
{
"iSystem": true,
"fromName": null,
"message": ["李四", "王五"]
}
// 2. 推送给某个人的消息格式
{
"iSystem": true,
"fromName": "张三",
"message": "你好"
}

配置环境

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

配置 WebSocketConfig

注入 ServerEndpointExporter 对象,使其自动注册使用了 @ServerEndpoint 注解的 Bean

@Configuration
public class WebSocketConfig {
// 注入对象 ServerEndpointExporter,这个 bean 会自动注册使用了 @ServerEndpoint 注解声明的 Websocket endpoint
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

然后别忘了跨域

@Configuration
public class GlobalCorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
// // 添加映射路径,“/**”表示对所有的路径实行全局跨域访问权限的设置
registry.addMapping("/**")
// 开放哪些ip、端口、域名的访问权限
// 例如:.allowedOrigins("http://127.0.0.1:5500")(用逗号分隔多个)
// 升级 springboot2.4.0 后, allowedOrigin 不能用通配符 *
.allowedOrigins("http://127.0.0.1:5500")
// 是否允许发送Cookie信息
.allowCredentials(true)
// 开放哪些Http方法,允许跨域访问 * 表示全部
.allowedMethods("GET", "POST", "PUT", "DELETE")
// 允许HTTP请求中的携带哪些Header信息
.allowedHeaders("*")
// 暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
// 这里必须一个个添加
.exposedHeaders(
"Content-Type",
"X-Requested-With",
"accept",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Allow-Origin",
"Access-Control-Request-Headers");
}
};
}
}

然后在浏览器的 edge://flags/ 暂时关闭这个设置 SameSite by default cookies 改成 Disable(这个新特性真的很烦啊)

POJO 存储信息

@AllArgsConstructor
@NoArgsConstructor
@Data
public class Message {
/**
* toName : 张三
* message : 你好
*/
@JsonProperty("toName")
private String toName;
@JsonProperty("message")
private String message;
}
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ResultMessage {
/**
* iSystem : true
* fromName : 张三
* message : 你好 or ["李四", "王五"]
*/
@JsonProperty("iSystem")
private boolean iSystem;
@JsonProperty("fromName")
private String fromName;
// 因为这个可能是数组
@JsonProperty("message")
private Object message;
}
@AllArgsConstructor
@NoArgsConstructor
@Data
public class Result {

/**
* flag : true
* message : ""
*/
@JsonProperty("flag")
private boolean flag;
@JsonProperty("message")
private String message;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
private String username;
private String password;
}

封装消息工具类

public class MessageUtil {
public static String getMessage(boolean isSystemMessage, String fromName, Object message) {
try {
ResultMessage resultMessage = new ResultMessage();
resultMessage.setISystem(isSystemMessage);
resultMessage.setMessage(message);
if (fromName != null) {
resultMessage.setFromName(fromName);
}
ObjectMapper mapper = new ObjectMapper();
// writeValueAsString 可以将任何 Java 值序列化为 String
return mapper.writeValueAsString(resultMessage);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
return null;
}
}

获取 HttpSession

public class GetHttpSessionConfigurator extends ServerEndpointConfig.Configurator {
// 虽然 WebSocket 不再是传统的 Request Response 模型,但是握手阶段还是进行了一次 HTTP 连接,所以可以在这里进行一次 HTTP 的操作
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
// 这里可以取得 Session
HttpSession httpSession = (HttpSession) request.getHttpSession();
// 可以将这个 HttpSession 存储到 ServerEndpointConfig 里面,使后面的 WebSocket 也能用到这个 HttpSession
// 这是一个 Map 对象,所以这里使用 HttpSession 的全路径当 key,防止忘记
sec.getUserProperties().put(HttpSession.class.getName(),httpSession);
}
}

创建 WebSocket Server

每连接一个就会创建一个 WebSocket Server 实例对象,这里 @ServerEndpoint 要把上面的 GetHttpSessionConfigurator 配置加上才能把 HttpSession 丢进 ServerEndpointConfig 里面

@Slf4j
@Component
@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfigurator.class) // 这里使用自定义的 GetHttpSessionConfigurator
public class ChatEndpoint {
// 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
private static int onlineCount = 0;

// 使用 synchronized 来保证线程安全
public static synchronized int getOnlineCount() {
return onlineCount;
}

public static synchronized void addOnlineCount() {
ChatEndpoint.onlineCount++;
}

public static synchronized void subOnlineCount() {
ChatEndpoint.onlineCount--;
}

// 用来存储每一个客户端对象所对应的 ChatEndpoint 对象(这里要使用线程安全的 Map)
private static Map<String, ChatEndpoint> onlineUser = new ConcurrentHashMap<>();

//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;

// 声明一个 HttpSession 这个是之前登陆时那个会话的 Session,在这里可以直接取得这个对象的用户名
private HttpSession httpSession;

/**
* 定义一个发送消息的方法
*
* @param message 传入的消息
* @throws IOException 抛出一个 IO 错误
*/
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
//this.session.getAsyncRemote().sendText(message);
}

/**
* 连接建立后触发的方法
*
* @param session 可选的参数。session为与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
@OnOpen
public void onOpen(Session session, EndpointConfig endpointConfig) throws IOException {
this.session = session;
// 因为在 GetHttpSessionConfigurator 里把 HttpSession 存进了 ServerEndpointConfig,所以这里可以直接取得
this.httpSession = (HttpSession) endpointConfig.getUserProperties().get(HttpSession.class.getName());

String username = (String) this.httpSession.getAttribute("username");


// 将当前对象存进集合里(先通过 HttpSession 取得名字)
onlineUser.put(username, this);
addOnlineCount();
log.info("{} 连接上了, 当前有 {} 人",username, getOnlineCount());

// 通知全部人当前的 username 上线了(使用前面定义的消息工具类)
// 因为通知全部人的消息是全部人的用户名,所以这里直接获取 key 就行了
String message = MessageUtil.getMessage(true, null, onlineUser.keySet());

//群发消息
for (ChatEndpoint item : onlineUser.values()) {
try {
item.sendMessage(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}


/**
* 连接关闭后触发的方法
*/
@OnClose
public void onClose() {
String username = (String) this.httpSession.getAttribute("username");
onlineUser.remove(username);
subOnlineCount();
// 移除这个对象
log.info("{} 离线了, 当前有 {} 人",username, getOnlineCount());
// 同理,离线了也要通知全部人
String message = MessageUtil.getMessage(true, null, onlineUser.keySet());

//群发消息
for (ChatEndpoint item : onlineUser.values()) {
try {
item.sendMessage(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}


/**
* 收到客户端消息后调用的方法
*
* @param json 客户端发送过来的消息
* @param session 可选的参数
*/
@OnMessage
public void onMessage(String json, Session session) throws Exception {
// 先把 message 转换成对象
ObjectMapper mapper = new ObjectMapper();
// 将这个 json 转换成自定义的 POJO 类型
Message data = mapper.readValue(json, Message.class);
log.info("收到发给 {} 的消息 \"{}\"", data.getToName() ,data.getMessage());
// 取得当前用户
String username = (String) this.httpSession.getAttribute("username");
// 整理成 json 格式
String message = MessageUtil.getMessage(false, username, data.getMessage());
// 推送给用户
onlineUser.get(data.getToName()).sendMessage(message);
}

/**
* 发生错误时调用
*
* @param session 可选的参数
* @param error 错误
*/
@OnError
public void onError(Session session, Throwable error) {
System.out.println("发生错误");
error.printStackTrace();
}
}

前端的代码

这里使用 Vue 来搭个简易的环境

<body>
<div id="app">
<!-- 这里懒得跳转到新页面,所以直接使用 v-show 来模拟了 -->
<div v-show="showLogin">
用户名:<input type="text" v-model:value="username"><br/>
密码:<input type="password" v-model:value="password"><br/>
<button @click='login()'>登陆</button>
</div>
<div v-show="!showLogin">
不要点自己进行聊天,这里懒得判断是不是自己了<br/>
当前用户: {{currentUsername}}<br/>
当前所有用户:<br/>
<ol v-for="(item, index) in allUser" :key="index">
<li @click="sendUser = item" style="width: 50px; background-color: cadetblue;">{{item}}</li>
</ol>
<br/>
接收到的消息:{{currentMessage}}
<br/>
发消息给:{{sendUser}} === <input type="text" v-model:value="sendMessage">
<button @click="sendForUser()">发送消息</button>
</div>
</div>
</body>
<!-- 开发环境版本,包含了有帮助的命令行警告 -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<!-- 引入 axios -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.bootcss.com/qs/6.5.1/qs.min.js"></script>
const qs = Qs // 引入QS库把请求参数转成表单形式
axios.defaults.timeout = 3000; // 设置超时时间
// 设置请求的基准URL地址
axios.defaults.baseURL = 'http://localhost:8080/';
// 使请求可以携带 Cookie
axios.defaults.withCredentials = true;

new Vue({
el: '#app',
data() {
return {
showLogin: true, // 用于判断是否显示登陆窗口,不显示登陆窗口则显示聊天窗
username: '',
password: '123',
currentUsername: '', // 存储自己的名字
allUser: [], // 存储全部用户的名字
ws: {}, // 把 WebSocket 对象提升到全局,方便别的方法调用
sendUser: '', // 要进行聊天的对象
sendMessage: '', // 要发送的消息
currentMessage: '' //接收到的消息
}
},
methods: {
sendForUser() {
// 发送消息,先整理成 JSON 格式
let json = {
toName: this.sendUser,
message : this.sendMessage
}
this.ws.send(JSON.stringify(json));
},
chat() {
this.ws = new WebSocket("ws://localhost:8080/chat");
// 然后绑定事件
this.ws.onopen = (event) => {
console.log('连接上聊天室');
}
// 接收到服务端推送的消息
this.ws.onmessage = (event) => {
const messageObj = JSON.parse(event.data);
console.log(messageObj);
if (messageObj.iSystem) {
console.log('系统消息');
this.allUser = messageObj.message;
} else {
console.log('用户消息');
this.currentMessage = messageObj.message;
}
}
this.ws.onclose = (event) => {
}
},
login() {
axios.post('/login', qs.stringify({
username: this.username,
password: this.password
}), {
headers: {
// 表单类型
'Content-Type': 'application/x-www-form-urlencoded'
}
})
.then((result) => {
console.log(result.data);
if (result.data.flag) {
this.showLogin = false
// 模拟登陆进去后通过 session 取得用户名
axios.get('/getusername')
.then((result) => {
this.currentUsername = result.data
// 然后就可以连接 WebSocket
this.chat()
}).catch((err) => {
});
}
}).catch((err) => {
console.log(err);
});
}
},
})